Spotify to szwedzki serwis strumieniowy oferujący dostęp do ponad 100 milionów utworów oraz 6 milionów podcastów. Posiada on oficjalne API, które umożliwia pobieranie metadanych opisujących treści portalu i tworzenie na ich podstawie aplikacji komunikujących się z portalem, takich jak system rekomendacji. Zmienne opisujące utwory muzyczne, które można wyodrębnić za jego pomocą, to między innymi energiczność, taneczność, czy tempo utworu, co pozwala pomyśleć, że można za ich pomocą sklasyfikować piosenkę do konkretnego gatunku. Żeby spróbować tego dokonać, postanowiłem podjąć próbę budowy optymalnego modelu klasyfikacji wieloklasowej, który na podstawie pozyskanych metadanych będzie przyporządkowywał utworowi jeden z gatunków muzycznych.
Sposób pozyskania danych
Wybór klas zmiennej zależnej
Na świecie istnieje szeroka gama gatunków muzycznych: od mongolskich śpiewów gardłowych, przez catstep aż po pirate metal. W swoim projekcie musiałem się jednak ograniczyć do zaledwie kilku, fundamentalnych gatunków, które dobrze uogólniałyby tę różnorodność.
Źródło: https://everynoise.com/
Serwis FreeDB jest darmową bazą danych niegdyś służącą osobom wypalającym płyty CD do wstępnego uzupełniania informacji o utworach. Opisuje ona każdą znajdującą się tam piosenkę za pomocą jednego z następujących gatunków: blues, muzyka klasyczna, country, folk, jazz, newage, reggae, rock, soundtrack. Uznałem, że jest to całkiem kompletna lista podstawowych gatunków muzycznych. Pozwoliłem sobie jednak zamienić soundtrack i newage na hip-hop i metal, a następnie na jej podstawie określiłem stany, które będzie przyjmowała zmienna zależna w moim modelu. Ostatecznie więc, lista stanów, które będzie mogła przyjąć zmienna określająca gatunek, wygląda następująco:
blues,
muzyka klasyczna,
country,
folk,
jazz,
hip-hop,
reggae,
rock,
metal.
Stworzenie zbioru danych za pomocą API
API Spotify umożliwia jednoczesne pobranie danych dla wszystkich utworów z wybranego albumu, profilu artysty lub playlisty. W celu stworzenia zbioru danych wyszukałem więc w serwisie po kilka playlist zawierających utwory danego gatunku tak, żeby na jeden gatunek przypadało 200-300 obserwacji i zbiór był możliwie zrównoważony, a następnie wyniki złączyłem w jedną ramkę danych.
Code
Sys.setenv(SPOTIFY_CLIENT_ID ='8e358bf3471a4232babc75cfae3ffffc')Sys.setenv(SPOTIFY_CLIENT_SECRET ='34b94c88be5a4eba98dad34d17b68f22')Sys.setenv(SPOTIFY_REDIRECT_URI ='http://localhost:3036')playlist_names <-c("Blues Classics", "Blues Standards", "Electric Blues Classics", "Classic Blues Guitar","Classical Essentials", "Classical New Releases", "Country's Greatest Hits", "Hot Country", "Country Top 50", "Fresh Folk", "Roots Rising", "jazz classics the best tunes in jazz history", "Hip-Hop Drive", "Gold School", "RAP GENERACJA","Reggae Classics", "Summer Sunshine Reggae", "celebrating the film bob marley one love", "Reggae Party", "Rock Classics", "All New Rock","Metal Essentials", "00s Metal Classics", "10s Metal Classics" )pb <-txtProgressBar(min =0,max =length(playlist_names),style =3,width =50,char ="=") dataset <-NULLfor (p in1:length(playlist_names)) { search_results <-search_spotify(q = playlist_names[p], type =c("playlist"), authorization =get_spotify_access_token() ) playlist_id <- search_results %>%select(id, name, uri) %>%slice_head(n =1) %>%select(id) %>%pull() p_dt <-get_playlist_audio_features(username ="Spotify",playlist_uris =c(playlist_id), )if (p ==1) { dataset <- p_dt }else { dataset <-rbind(dataset, p_dt) }setTxtProgressBar(pb, p)}df <-as.data.frame(dataset)df <- df[,-which(sapply(df, class) =="list")]# write.csv(df, "spotify-genres-classification.csv")# write.csv(df, "spotify-genres-classification-new.csv")
Przetwarzanie i czyszczenie zbioru
Wstępna selekcja cech
Zbiór pierwotnie zawiera wiele niepotrzebnych zmiennych, takich jak identyfikator playlisty, z której pochodzi dany utwór czy link do okładki każdej piosenki.
Głośność utworu w decybelach - zmienna ciągła przyjmująca wartości ujemne
mode
Zmienna kategoryczna przyjmująca wartość 1 jeśli utwór jest w skali molowej i 0 jeśli utwór jest w skali durowej
speechiness
“Recytowalność” tekstu utworu - zmienna ciągła przyjmująca wartości w przedziale [0, 1]
acousticness
Określa szansę na to, że utwór jest akustyczny - zmienna ciągła przyjmująca wartości w przedziale [0, 1]
instrumentalness
Określa szansę na to, że utwór nie zawiera wokalu - zmienna ciągła przyjmująca wartości w przedziale [0, 1]
liveness
Określa szansę na to, że utwór był wykonywany na żywo - zmienna ciągła przyjmująca wartości w przedziale [0, 1]
valence
Określa jak bardzo pozytywnie jest nastrojony utwór - zmienna ciągła przyjmująca wartości w przedziale [0, 1]
tempo
Zmienna ciągła określająca tempo piosenki w bpm (beats per minute)
time_signature
Zmienna kategoryczna określająca metrum muzyczne, gdzie np. wartość 3 oznacza metrum 3/4
track.explicit
Zmienna kategoryczna przyjmująca wartość TRUE dla utworów z treściami wulgarnymi i FALSE dla pozostałych
track.duration_ms
Zmienna ciągła określająca długość trwania utworu w milisekundach
track_name
Identyfikator określający nazwę utworu
track_popularity
Popularność utworu - zmienna ciągła przyjmująca wartości w przedziale [0, 100]
track.album.release_date
Data wydania utworu
key_name
Zmienna kategoryczna określająca skalę, w jakiej jest utwór
mode
Zmienna kategoryczna określająca skalę, przyjmująca wartość minor lub major
key_mode
Połączenie zmiennej key_name i key_mode
Note
Celowo zostawiłem zmienną track_name będącą identyfikatorem w celu ułatwienia pracy ze zbiorem oraz interpretacji wyników.
Patrząc na powyższy zestaw cech od razu można zauważyć, że zmienna mode_name jest nadmiarowa w stosunku do zmiennej mode, key_name jest nadmiarowa w stosunku do key, a key_mode będąca kombinacją poprzednich zmiennych również jest zbyteczna, od razu więc się ich pozbywam.
Utworzenie zmiennej zależnej określającej gatunek utworu
Zmienna określająca gatunek nie była częścią zbioru cech pozyskanych bezpośrednio z API, dlatego muszę ją stworzyć samodzielnie korzystając ze zmiennej określającej nazwę playlisty, z której pochodzi utwór.
Liczba utworów przynależących do poszczególnych gatunków prezentuje się następująco. Widać, że zgodnie z zamierzeniem każdy gatunek posiada 200-300 obserwacji, dzięki czemu zbiór jest w miarę zbilansowany.
Variable
Frequency
blues
238
classical
227
country
200
folk
250
hiphop
250
jazz
250
metal
275
reggae
246
rock
250
Zmienna genre określająca gatunek jest już utworzona, więc mogę się pozbyć cechy playlist_name.
Code
df <- df %>%select(-playlist_name)
Sprawdzenie obecności wartości brakujących
Code
any(is.na(df))
[1] TRUE
danceability
energy
key
loudness
mode
speechiness
acousticness
instrumentalness
liveness
valence
tempo
time_signature
track.explicit
track.duration_ms
track.name
track.popularity
track.album.release_date
genre
402
NA
NA
NA
NA
NA
NA
NA
NA
NA
NA
NA
NA
NA
NA
NA
NA
NA
classical
Wygląda na to, że zbiór posiada wartości brakujące. Po ich wyświetleniu okazuje się jednak, że stanowi je pojedynczy wiersz, w którym wartości każdej zmiennej z wyjątkiem genre to NA. Jest to prawdopodobnie spowodowane tym, że API pobrało z playlisty muzyki klasycznej utwór, który nie jest już dostępny w serwisie. Z racji tego, że jest to jedyny wiersz z wybrakowanymi wartościami w całym zbiorze, śmiało można się go pozbyć bez konieczności imputacji danych.
Code
df <-na.omit(df)any(is.na(df))
[1] FALSE
Sprawdzenie obecności duplikatów
Na obserwacje z poszczególnych gatunków przeważnie składają się utwory z kilku różnych playlist. Nie jest jednak wykluczone, że w kilku playlistach dla jednego gatunku pewna piosenka się powtórzyła. Należy więc wybadać, czy taka sytuacja wystąpiła i mamy do czynienia z duplikatami.
Is duplicated
Frequency
FALSE
2023
TRUE
162
Okazuje się, że w naszym zbiorze powtórzyły się aż 162 utwory. Należy usunąć ich powtórzenia, aby wyeliminować problem identycznych obserwacji.
Code
df <- df %>%distinct(track.name, .keep_all =TRUE)
Po zostawieniu w zbiorze wierszy o unikalnych tytułach problem zostaje rozwiązany.
Wyświetlenie typu i przykładowych obserwacji dla każdej zmiennej od razu pozwala nam zauważyć parę problemów ze zbiorem, którymi trzeba będzie się zająć. Są to między innymi niepoprawne typy danych lub to, że zmienna przedstawiająca datę, w której opublikowany został utwór, czasami jest wyrażona w dokładności co do nia, a czasami co do roku wydania.
statistic
min
median
mean
max
sd
danceability
0.0000000
0.5520000
0.5421811
0.9750000
0.1858526
energy
0.0021200
0.5820000
0.5495664
0.9980000
0.2891074
loudness
-43.738000
-8.320000
-10.197988
-1.299000
6.514778
speechiness
0.00000000
0.04630000
0.08191068
0.60200000
0.08278790
acousticness
0.00000232
0.25000000
0.38580650
0.99600000
0.36847665
instrumentalness
0.0000000
0.0004180
0.1550245
0.9580000
0.2981258
liveness
0.0243000
0.1220000
0.1740335
0.9790000
0.1368237
valence
0.0000000
0.4910000
0.4870162
0.9700000
0.2618542
tempo
0.00000
115.48600
117.53169
208.57600
31.57082
track.duration_ms
77032.0
230466.0
257778.4
1637893.0
117245.8
track.popularity
0.00000
49.00000
45.40929
93.00000
24.02641
Statystyki opisowe dla zmiennych ciągłych również obrazują kilka anomalii w naszym zbiorze. Widać na przykład, że istnieje utwór z tempem równym 0, co jest niemożliwe. Istnieją również obserwacje o zerowej popularności, co jest mało prawdopodobne biorąc pod uwagę to, że praktycznie każda playlista, z której zostały pobrane dane, zawierała klasyki gatunku.
Serenade for Strings in E Major, Op. 22, B. 52: II. Tempo di valse
65
1991-01-01
classical
Po wyświetleniu wszystkich wierszy, które mają tempo poniżej 40 bpm okazuje się, że jest tylko jeden taki utwór. Można go więc usunąć bez obawy, że spowoduje to wielką różnicę w zbiorze.
Wyświetlenie histogramu zmiennej popularity pozwala zauważyć, że mamy bardzo dużo zmiennych o popularności równej lub bliskiej 0. Sprawdźmy, ile wierszy ma “zerową” popularność.
[1] "Repentless"
[2] "Storytime"
[3] "Faster"
[4] "Unbreakable"
[5] "To Hell And Back"
[6] "Reign Of Darkness"
[7] "The Dirt (Est. 1981) [feat. Machine Gun Kelly]"
[8] "Koolaid"
[9] "Looking Down The Barrel Of Today"
[10] "My Own Grave"
Mamy aż 245 takich wierszy. Można więc wywnioskować, że zmienna ta nie jest definiowana przez serwis Spotify w prawidłowy sposób i zawiera nieprawdopodobne wartości, co potwierdza fakt, że utwór Slayera “Repentless” również ma “zerową” popularność. Sprawiło to, że skłoniłem się do wyeliminowania jej ze zbioru danych.
Code
df <- df %>%select(-track.popularity)
Zmiana wartości w zmiennej time_signature
Value
Frequency
1
18
3
209
4
1771
5
24
Po wyświetleniu liczby utworów w poszczególnym metrum okazuje się, że zbiór jest zdominowany przez utwory w najpopularniejszym metrum, jakim jest 4/4. Podejrzane jest jednak to, że mamy 18 utworów w metrum 1/4, co najprawdopodobniej jest błędem. Przyjrzyjmy się utworom w tym metrum, żeby zidentyfikować źródło takiego kodowania.
Code
tail(df[df$time_signature ==1, ]$track.name)
[1] "Die tote Stadt, Op. 12: Mein Sehnen, mein Wähnen (Pierrot's Tanzlied)"
[2] "Hard To Tell"
[3] "Wanna Get To Know You"
[4] "Solsbury Hill"
[5] "Money - 2011 Remastered Version"
[6] "Pneuma"
Okazuje się, że metrum to jest przypisane albo utworom, które posiadają “płynną” strukturę, charakterystyczną dla muzyki klasycznej, albo tym, które cechuje niestabilne metrum, czyli takie, które często zmienia się w trakcie trwania utworu. Dobrym przykładem jest utwór “Money” zespołu Pink Floyd, (motyw główny jest w 7/4, ale potem podczas “mostku” przechodzi w 4/4, a następnie 6/4, żeby potem znowu wrócić do 7/4), lub utwór “Pneuma” zespołu Tool, gdzie metrum zmienia się praktycznie cały czas (posiada on nawet powtarzający się segment, w którym mamy do czynienia z następującymi po sobie taktami w metrum 33/16, 33/16, 28/16 i 30/16!).
Nic więc dziwnego, że API przy wyodrębnianiu metrum z tych piosenek “zwariowało” i sklasyfikowało je jako 1/4, co oznaczałoby mniej więcej, że na cały takt przypada jedno uderzenie, co oczywiście jest w tym przypadku nieprawdą. Z racji tego, że utwory w metrum 3/4 i 5/4, zarówno jak “utwory w metrum 1/4” występują stosunkowo rzadko, postanowiłem złączyć je w jedną kategorię “other”, natomiast utwory w metrum 4/4 pozostawić niezmienione.
Tak prezentuje się teraz podział utworów ze względu na obecność metrum 4/4 lub innego.
Value
Frequency
4
1771
other
251
Zmiana zmiennej track.album.release_date na zmienną decade
Z poprzednich wnioskowań wyniknęło, że zmienna przedstawiająca datę wydania utworu jest wyrażana z różną dokładnością, ponieważ czasem mamy w niej podaną całą datę, a czasami tylko rok. W celu ujednolicenia dokładności wartości tej cechy zdecydowałem się z każdej daty wyciągnąć tylko rok. Następnie uznałem, że dobrym pomysłem byłoby przypisanie każdego roku do poszczególnej dekady, w celu skategoryzowania zmiennej. Piosenek z lat 30’, 40’, 50’ i 60’ było mało, więc zgrupowałem je do jednej kategorii “other”, żeby grupy były bardziej zbilansowane.
W ten sposób utworzyłem kategoryczną zmienną decade, która przyjmuję następujące wartości z daną częstotliwością:
Value
Frequency
<70
233
70
140
80
126
90
227
00
447
10
265
20
584
Zmiana wartości w kolumnie track.explicit na 0 i 1
Zmienna track.explicit przyjmuje wartości logiczne TRUE lub FALSE, dlatego decyduję się je zamienić na wartości 1 i 0, gdzie 1 odpowiada utworowi, który zawiera treści wulgarne.
Zmiana jednostki w zmiennej track.duration_ms na sekundy
Zmienna track.duration_ms jest wyrażana w milisekundach, przez co przyjmuje bardzo duże wartości. W celu ułatwienia interpretacji tej cechy zamienię jednostkę na sekundy dzieląc jej wartości przez 1000.
Wyraźnie widać, że część zmiennych ciągłych ma rozkłady asymetryczne gruboogonowe. Oznacza to, że niektóre modele uczenia maszynowego będą wymagały uprzedniej transformacji tych zmiennych w celu wymuszenia większej symetryczności rozkładów.
Można tu zauważyć kilka ciekawych faktów. Utwory bluesowe i jazzowe w zdecydowanej większości powstały dawniej niż w latach 70’, z kolei najwięcej utworów klasycznych w zbiorze pochodzi z lat 20’. Innym ciekawym spostrzeżeniem jest to, że prawie wszystkie piosenki folkowe pochodzą z lat 20’ -jedynie garstka z nich pochodzi jeszcze z lat 10’. Warto zaznaczyć, że rozkłady te nie odzwierciedlają jednak faktycznego procesu popularyzacji tych gatunków - odzwierciedlają one jedynie charakterystykę zbioru.
Treści wulgarne w zależności od gatunku
Nie jest zaskoczeniem fakt, że najwięcej utworów zawierających treści wulgarne pochodzi z gatunku hip-hop (zawiera je aż prawie 90% utworów z tego gatunku). Kolejny w kolejność jest metal, ponad 1/5 gatunków z tego utworu zawiera treści nieodpowiednie dla młodych odbiorców. Jedynymi gatunkami, w których nie ma żadnych wulgaryzmów są blues, muzyka klasyczna i jazz.
Energiczność vs. głośność utworów
Energiczność i głośność utworu to cechy, które z pozoru wydają się być powiązane. Powyższy wykres dowodzi, że rzeczywiście występuje taka zależność: im utwór jest bardziej energiczny, tym głośniejszy. Dodatkowo można zauważyć, że niektóre gatunki układają się w “chmury” - utwory metalowe są na przykład ściśle skupione w prawym górnym rogu wykresu, co oznacza że są jednocześnie najgłośniejsze i najbardziej energiczne.
Taneczność vs. pozytywność utworów
Czy to, że utwór jest bardziej pozytywny oznacza, że nadaje się również bardziej do tańczenia? Powyższy wykres pokazuje, że jest w tym trochę prawdy.Można zauważywć, że najbardziej tanecznym i pozytywnym gatunkiem jest reggae. Może natomiast dziwić fakt, iż wygląda na to, że wiele utworów klasycznych ze zbioru nie nadaje się do tańca i jest wyjątkowo depresyjna.
Głośność utworów na przestrzeni lat
Ciekawym faktem jest to, że w więkoszści gatunków utwory stawały się głośniejsze z dekady na dekadę. Nie jest zaskoczeniem, że metal prawie zawsze był najgłośniejszym gatunkiem, w tym latach 20’ charakteryzował się średnią głośnością na poziomie -3.23 dB, co znacznie kontrastuje z -23.68 dB w przypadku muzyki klasycznej z tej samej dekady.
Rozkłady czasu trwania utworów
Patrząc na wykresy pudełkowe długości piosenek w zależności od gatunku można podejrzewać, że jest szansa na to, że niektóre rozkłady są do siebie zbliżone. Żeby sprawdzić to sprawdzić zamierzam przeprowadzić test ANOVA. Najpierw sprawdzam jednak wymagane założenia o wielowymiarowej normalności w grupach, zbalansowaniu grup i ich homogeniczności.
genre
sample_size
p_value
blues
181
0.0000000
classical
224
0.0000000
country
187
0.0020730
folk
226
0.0095814
hiphop
238
0.0000016
jazz
236
0.0000000
metal
248
0.0000000
reggae
235
0.0000597
rock
247
0.0000000
Code
bartlett.test(df$track.duration_s, df$genre)
Bartlett test of homogeneity of variances
data: df$track.duration_s and df$genre
Bartlett's K-squared = 1323, df = 8, p-value < 2.2e-16
Wygląda na to, że żadne z założeń nie jest spełnione, zatem przeprowadzę test Kruskala-Wallisa będący parametrycznym odpowiednikiem testu ANOVA.
Code
kruskal.test(track.duration_s ~ genre, df)
Kruskal-Wallis rank sum test
data: track.duration_s by genre
Kruskal-Wallis chi-squared = 278.84, df = 8, p-value < 2.2e-16
Test ten wykazał, że należy odrzucić hipotezę mówiącą o tym, że średnie długości piosenek w gatunkach są na takim samym poziomie. Żeby sprawdzić, które z nich się różnią pomiędzy sobą, przeprowadzę test Dunna.
Na wykresie pokazane są tylko wartości p-value, które były istotne statystycznie. Okazuje się więc, że przypuszczenia były niesłuszne - w każdym przypadku należy odrzucić hipotezę, że średnia długość utworów jest taka sama.
Zależność zmiennej valence od skali
W praktyce, skale molowe i oparte na nich utwory muzyczne uważa się za mające smutne brzmienie, w odróżnieniu od skal durowych, które uważa się za radosne.
Patrząc na wykresy pudełkowe wygląda na to, że zmienna valence nie zależy od tego, czy utwór jest w skali durowej czy molowej. Potwierdzenie otrzymamy przeprowadzając test t-Studenta wcześniej sprawdzając założenie o jednorodności wariancji.
F test to compare two variances
data: df[df$mode == 0, ]$valence and df[df$mode == 1, ]$valence
F = 1.0126, num df = 661, denom df = 1359, p-value = 0.8461
alternative hypothesis: true ratio of variances is not equal to 1
95 percent confidence interval:
0.8891214 1.1566664
sample estimates:
ratio of variances
1.012574
Założenie o jednorodności wariancji jest spełnione. Przeprowadzę zatem t-test.
Code
t.test(valence ~ mode, data = df)
Welch Two Sample t-test
data: valence by mode
t = -1.4898, df = 1302.8, p-value = 0.1365
alternative hypothesis: true difference in means between group 0 and group 1 is not equal to 0
95 percent confidence interval:
-0.042885818 0.005864111
sample estimates:
mean in group 0 mean in group 1
0.4748066 0.4933175
P-wartość przeprowadzonego testu wskazuje na to, że nie ma podstaw do odrzucenia hipotezy o równości średnich w tych rozkładach. Oznacza to, że w naszym zbiorze cecha mówiąca o nastroju utworu nie zależy od skali, co jest dosyć ciekawym wnioskiem.
Korelacja pomiędzy zmiennymi niezależnymi ciągłymi
Wysoka korelacja między zmiennymi zależnymi może stanowić problem podczas budowy modelu uczenia maszynowego. Wiele źródeł podaje, że można ją uznać za wysoką, jeżeli przekracza wartość 0.7. W naszym zbiorze wartość ta jest przekraczana trzy razy, pomiędzy parami zmiennych:
Na wykresach pomiędzy tymi zmiennymi widoczne są odkryte zależności, szczególnie w przypadku energiczności i głośności, gdzie obserwacje dosyć wyraźnie układają się wzdłuż krzywej dopasowanej wielomianem drugiego stopnia.
Na wykresie trójwymiarowym widać jak bardzo te trzy cechy zależą od siebie nawzajem.
Żeby sprawdzić, czy możliwe będzie zastąpienie tych trzech zmiennych tylko jedną z nich, przeprowadzę PCA w celu sprowadzenia trzech wymiarów do jednego, a następnie sprawdzę, która z tych zmiennych ma największy wpływ na kształtowanie tego wymiaru.
Z powyższego wykresu widać, że pierwszy wymiar wyjaśnia aż około 80% zmienności.
Wykres wkładu zmiennych w pierwszy wymiar oraz linia odcięcia sugerują, że zmienna energy ma istotnie największy wpływ w tworzenie się tego wymiaru. Na tej podstawie decyduję się więc na to, żeby z trójki zmiennych energy, acousticness i loudness zostawić tylko energy, co wyeliminuje problem wysokiej korelacji pomiędzy częścią zmiennych zależnych w zbiorze. Później sprawdzę też jednak jak będzie się spisywał model zbudowany na zbiorze z usuniętymi zmiennymi. “Wysoka korelacja” jest bowiem płynnym określeniem - niektóre źródła podają, że zaczyna się ona już od wartości 0.7, natomiast czasami o wysokiej korelacji mówi się dopiero od 0.9. Jest zatem szansa, że korelacja, która tu wystąpiła, nie miała wpływu na przyszłą skuteczność predykcyjną modelu.
Zbiór nie zawiera już braków danych ani duplikatów, wyselekcjonowane zmienne są poprawnie zakodowane i nie zawierają nieprawidłowości, a problem wysokiej korelacji został wyeliminowany, dlatego można przejść do przygotowywania modelu przewidującego gatunek muzyczny.
Podział zbioru na treningowy i testowy
Zbiór dzielę na treningowy i testowy w proporcjach 0.8 ustawiając parametr strata o wartości “genre”, aby proporcje liczebności grup zmiennej objaśnianej były zachowane.
W poniższej tabeli przedstawione są modele, których zamierzam użyć do budowy modeli predykcyjnych na podstawie mojego zbioru, a także rodzaj preprocessingu, który jest dla nich rekomendowany.
Model
Dummy vars
Zero-variance
Decorrelate
Normalize
Transform
Random forest
✗
◌
◌
✗
✗
Bagging
✗
✗
◌
✗
✗
XGBoost
✗
◌
◌
✗
✗
Mlp
✓
✓
✓
✓
✓
Knn
✓
✓
◌
✓
✓
Svm
✓
✓
✓
✓
✓
Naive Bayes
✗
✓
◌
✗
✗
Rodzaje preprocessingu:
dummy vars - przekształcenie zmiennych kategorycznych w tzw. dummy variables
zero-variance - usunięcie zmiennych, które zawierają tylko pojedynczą wartość
decorrelate - wyeliminowanie problemu wysokiej korelacji pomiędzy zmiennymi objaśniającymi
normalize - normalizacja
transform - wymuszenie większej symetryczności rozkładu asymetrczynych zmiennych
✓ oznacza, że dany rodzaj preprocessingu jest rekomendowany, ✗ - przeciwnie, natomiast ◌ oznacza, że dany zabieg może, ale nie musi pomóc w osiągnięciu lepszych wyników.
Zbiór po uprzednich zabiegach nie posiada już problemu wysokiej korelacji, więc nie wymaga dalszej “dekorelacji”. Nie zawiera on także zmiennych zawierających pojedynczą wartość, jednak zawrę ten element preprocessingu w recepturach na wszelki wypadek.
Na podstawie tabelki tworzę trzy receptury:
basic_recipe określa predyktory i zmienną objaśnianą w modelu, nadaje zmiennej track_name inną rolę, żeby nie mogła być używana przy trenowaniu modelu, a także usuwane są potencjalne zmienne przyjmujące pojedynczą wartość,
dummy_recipe dodatkowo zamienia zmienne kategoryczne na dummy variables,
transform_recipe dodatkowo transformuje zmienne numeryczne za pomocą transformacji Yeo-Johnsona i je normalizuje.
Code
basic_recipe <-recipe(genre~., data = spotify_train) %>%update_role(track.name, new_role ="track_name") %>%step_zv(all_predictors())
# A tibble: 15 × 4
variable type role source
<chr> <list> <chr> <chr>
1 danceability <chr [2]> predictor original
2 energy <chr [2]> predictor original
3 key <chr [3]> predictor original
4 mode <chr [3]> predictor original
5 speechiness <chr [2]> predictor original
6 instrumentalness <chr [2]> predictor original
7 liveness <chr [2]> predictor original
8 valence <chr [2]> predictor original
9 tempo <chr [2]> predictor original
10 time_signature <chr [3]> predictor original
11 track.explicit <chr [3]> predictor original
12 track.name <chr [3]> track_name original
13 decade <chr [3]> predictor original
14 track.duration_s <chr [2]> predictor original
15 genre <chr [3]> outcome original
Przy poszukiwaniu najlepszego modelu o najlepszych parametrach będę posługiwał się następującymi metrykami: accuracy, bal_accuracy, precision, recall, sensitivity oraz specificity.
Za pomocą tych samych kroków znajdę również najlepsze parametry dla zbioru, w którym nie usunąłem zmiennych loudness i acousticness. Podział danych na zbiór treningowy i testowy będzie taki sam, dzięki czemu będzie można zobaczyć gdzie modele się różniły.
Wygląda na to, że według każdej metryki model lasu losowego okazuje się być najlepszy. Dodatkowo wygląda na to, że model zbudowany na podstawie zbioru ze zmiennymi loudness i acousticness jest minimalnie lepszy.
Po przyjęciu za główną metrykę porównawczą balanced accuracy model lasu losowego również zdecydowanie wygląda na najlepszy. Można więc na tym etapie przypuszczać, że wyeliminowanie korelacji nie wpłynęło na poprawę modelu, a nawet nieznacznie ją pogorszyło, co jednak zweryfikuję później ostatecznie sprawdzając dokładność każdego z modeli na zbiorze testowym.
Zestawienie najlepszego modelu każdego rodzaju potwierdza, że w naszym zadaniu najlepiej poradziły sobie modele “drzewiaste”, najsłabiej natomiast spisał się naiwny klasyfikator bayesowski. Dodatkowo wyraźnie widać, że zostawienie usuniętych wcześniej zmiennych poprawiło średnią dokładność każdego z modeli. Kolejność modeli w zależności od użytego zbioru od najdokładniejszego do najmniej dokładnego również jest podobna - jedynie model svm okazał się minimalnie lepszy i wyprzedził model mlp w przypadku modeli zbudowanych za pomocą zbioru z większą ilością zmiennych.
Stworzenie najlepszych modeli o parametrach wybranych przez przeszukiwanie siatki
Ostatecznie decyduję się na stworzenie dwóch modeli lasu losowego dla każdego zbioru, ponieważ w każdym zestawieniu radził on sobie najlepiej. Jako model1 będę od teraz oznaczał model bez dodatkowych zmiennych, drugi natomiast będzie oznaczany jako model2.
Sprawdzenie dokładności modelu na zbiorze testowym
Ostatecznie o tym, który model spisuje się dokładniej i jaka jest faktyczna zdolność predykcyjna zbudowanych modeli można się przekonać sprawdzając ich zdolności na zbiorze testowym.
Przy porównaniu metryk na zbiorach testowych ostatecznie widać, że Model2 ma lepsze zdolności generalizacyjne. Wartość metryki bal_accuracy wynosząca 0.87 jest zadowalająca i oznacza, że model dobrze radzi sobie na danych, których wcześniej nie widział. Różnica między metrykami obydwu modeli nie jest bardzo duża, jednak z pewnością będzie zauważalna na macierzy klasyfikacji.
Macierze klasyfikacji
Tutaj rzeczywiście rzuca się w oczy poprawa modelu o większej ilości zmiennych. Praktycznie każdy gatunek (z wyjątkiem folku i bluesa) ma teraz więcej poprawnie sklasyfikowanych obserwacji.
Z powyższych wykresów można wysnuć wiele ciekawych wniosków:
każdy model najczęściej, bo aż 10 razy, pomylił blues z jazzem, co jest dosyć zrozumiałym błędem zważając na podobieństwo między tymi gatunkami
w Model2country zostało pomylone z największą ilością gatunku, ponieważ oprócz rzeczywistej wartości model przewidywał w jego przypadku aż 6 innych gatunków muzycznych
hip-hop, jeżeli był mylnie klasyfikowany, to tylko jako reggae.
Dzięki temu, że nie usunąłem ze zbioru zmiennej określającej tytuł piosenki mogę teraz dokładniej przyjrzeć się poszczególnym błędom. Najbardziej przykuł moją uwagę jeden utwór jazzowy, który w Model1 został pomylony z utworem metalowym.
track.name
genre
.pred_class
Inner Urge - Rudy Van Gelder Edition; 2004 Digital Remaster
jazz
metal
Po jego wysłuchaniu staje się zrozumiałe, dlaczego model pomylił się w tym przypadku. Jest to utwór szybki, agresywny i ponury i mimo że jazzowy, to ma on w sobie elementy metalu. Model2 nie popełnił już tego błędu; można podejrzewać, że stało się to za sprawą przywrócenia w zbiorze zmiennej acousticness - jest ona bowiem cechą, która zdecydowanie różni pomiędzy sobą te gatunki i najprawdopodobniej informacje, które ze sobą wniosła do Model2 wyeliminowały tę pomyłkę.
Inną pomyłką, którą można wziąć pod lupę jest utwór jazz sklasyfikowany jako country w Model2.
track.name
genre
.pred_class
The Sidewinder - Remastered 1999/Rudy Van Gelder Edition
jazz
country
Po jego wysłuchaniu również można zrozumieć pomyłkę. Jest to zdecydowanie utwór jazzowy, jednak brzmi on też jednocześnie trochę jak “piosenka z westernu”.
Są tu też obecne pomyłki, dla których nie ma żadnego logicznego wyjaśnienia, jak sklasyfikowanie utworu Linkin Park “What I’ve Done” jako country, czy kompozycji Vivaldi’ego jako reggae.
track.name
genre
.pred_class
What I've Done
metal
country
track.name
genre
.pred_class
Recomposed By Max Richter: Vivaldi, The Four Seasons: Spring 1 - 2012
Z krzywych ROC stworzonych za pomocą metody one vs. all dla każdego gatunku można znaleźć potwierdzenie tego, co można było zobaczyć już na macierzy konfuzji - model najlepiej radził sobie z przewidywaniem metalu i hiphopu, natomiast najgorzej radził sobie z klasyfikacją bluesa, rocka i country.
Z powyższego wykresu można zauważyć, że najważniejszą cechą przy podziałach stosowanych w tworzeniu optymalnego modelu lasu losowego Model1 było energy, danceability oraz danceability. W Modelu2 najważniejsza jest natomiast decade, a w drugiej kolejności acousticness, które na podstawie wysokiej korelacji zostało usunięte w pierwszym modelu. Widać więc, że korelację tą należało w tym przypadku zignorować, ponieważ nie dość że model Model2 charakteryzował się lepszą dokładnością, to okazuje się, że wysoko skorelowana z inną zmienna acousticness w znacznym stopniu przyczyniła się do tworzenia podziałów w lasie losowym, który okazał się najlepszym modelem.
W obu przypadkach najmniej przydały się natomiast zmienne key, mode oraz time_signature, co nie jest zaskoczeniem, ponieważ gatunki nigdy nie ogarniczają się do konkretnych skal ani struktur.
Z powyższego wykresu można wyciągnąć jeszcze jeden ważny wniosek: ponieważ dekada okazuje się istotną zmienną, można przypuszczać, że stworzone modele nie sprawdziłyby się dobrze w klasyfikacji utworów spoza zbioru, na podstawie którego został zbudowany. Utwory w zbiorze nie były bowiem wybierane losowo – sam dobierałem playlisty, z których pochodzą. Często ograniczały się one do konkretnych dekad, o czym świadczą ich nazwy, takie jak “10s Metal Classics”. W związku z tym nie można zakładać, że ilość utworów z danego gatunku w zbiorze jest reprezentatywna dla całego gatunku. Z tego powodu model prawdopodobnie nie byłby równie skuteczny, gdyby został przetestowany na zupełnie innym zbiorze testowym, ponieważ zmyliłyby go daty wydania, do których nie mógłby odnieść zasad, które tak dobrze działały w kontekście utworów z mojego zbioru.
Podsumowanie
W ramach projektu dokonałem skutecznej klasyfikacji gatunków muzycznych na podstawie metadanych z serwisu Spotify. Proces rozpoczął się od pobrania danych przez API serwisu Spotify, które następnie poddałem eksploracyjnej analizie w celu zidentyfikowania kluczowych cech wpływających na klasyfikację. Dane zostały odpowiednio przygotowane, a następnie na ich podstawie zbudowałem i przetestowałem różne modele uczenia maszynowego. Najlepszy model osiągnął dokładność 87.1% na zbiorze testowym. Projekt potwierdził, że metadane Spotify mogą efektywnie wspierać klasyfikację gatunków muzycznych, jednak najprawdopodobniej nie będą one aż tak skuteczne w przewidywaniu gatunków utworów spoza zbioru, który stworzyłem. Można jednak mieć nadzieję, że przy pobraniu dużo większej ilości danych w sposób bardziej losowy, można by było stworzyć model, który dawałby znacznie lepsze rezultaty na nieznanych obserwacjach.
Source Code
---title: "Klasyfikacja gatunku za pomocą metadanych Spotify"author: "Tymoteusz Romanowicz"format: html: toc: true include-after-body: file: toc.htmleditor: visualeditor_options: chunk_output_type: consoleexecute: warning: falsecode-fold: showcode-tools: trueembed-resources: true---```{r echo=FALSE}library(tidyverse)library(ggplot2)library(PerformanceAnalytics)library(corrplot)library(psych)library(hrbrthemes)library(agricolae)library(FSA)library(rstatix)library(ggpubr)library(plotly)library(tidymodels)library(recipes)library(rpart)library(dials)library(rules)library(baguette)library(parsnip)library(discrim)library(doParallel)library(sparsediscrim)library(cvms)library(kableExtra)library(htmlTable)library(shapr)library(vip)library(spotifyr)```# WstępSpotify to szwedzki serwis strumieniowy oferujący dostęp do ponad **100 milionów utworów** oraz **6 milionów podcastów**. Posiada on oficjalne API, które umożliwia pobieranie metadanych opisujących treści portalu i tworzenie na ich podstawie aplikacji komunikujących się z portalem, takich jak system rekomendacji. Zmienne opisujące utwory muzyczne, które można wyodrębnić za jego pomocą, to między innymi energiczność, taneczność, czy tempo utworu, co pozwala pomyśleć, że można za ich pomocą sklasyfikować piosenkę do konkretnego gatunku. Żeby spróbować tego dokonać, postanowiłem podjąć próbę budowy optymalnego modelu klasyfikacji wieloklasowej, który na podstawie pozyskanych metadanych będzie przyporządkowywał utworowi jeden z gatunków muzycznych.# Sposób pozyskania danych## Wybór klas zmiennej zależnejNa świecie istnieje szeroka gama gatunków muzycznych: od *mongolskich śpiewów gardłowych*, przez *catstep* aż po *pirate metal*. W swoim projekcie musiałem się jednak ograniczyć do zaledwie kilku, fundamentalnych gatunków, które dobrze uogólniałyby tę różnorodność.[{alt="Źródło: https://everynoise.com/" fig-align="center"}](https://everynoise.com/)Serwis **FreeDB** jest darmową bazą danych niegdyś służącą osobom wypalającym płyty CD do wstępnego uzupełniania informacji o utworach. Opisuje ona każdą znajdującą się tam piosenkę za pomocą jednego z następujących gatunków: blues, muzyka klasyczna, country, folk, jazz, newage, reggae, rock, soundtrack. Uznałem, że jest to całkiem kompletna lista podstawowych gatunków muzycznych. Pozwoliłem sobie jednak zamienić *soundtrack* i *newage* na *hip-hop* i *metal*, a następnie na jej podstawie określiłem stany, które będzie przyjmowała zmienna zależna w moim modelu. Ostatecznie więc, lista stanów, które będzie mogła przyjąć zmienna określająca gatunek, wygląda następująco:- blues,- muzyka klasyczna,- country,- folk,- jazz,- hip-hop,- reggae,- rock,- metal.## Stworzenie zbioru danych za pomocą APIAPI Spotify umożliwia jednoczesne pobranie danych dla wszystkich utworów z wybranego albumu, profilu artysty lub playlisty. W celu stworzenia zbioru danych wyszukałem więc w serwisie po kilka playlist zawierających utwory danego gatunku tak, żeby na jeden gatunek przypadało 200-300 obserwacji i zbiór był możliwie zrównoważony, a następnie wyniki złączyłem w jedną ramkę danych.```{r eval=FALSE}Sys.setenv(SPOTIFY_CLIENT_ID = '8e358bf3471a4232babc75cfae3ffffc')Sys.setenv(SPOTIFY_CLIENT_SECRET = '34b94c88be5a4eba98dad34d17b68f22')Sys.setenv(SPOTIFY_REDIRECT_URI = 'http://localhost:3036')playlist_names <- c( "Blues Classics", "Blues Standards", "Electric Blues Classics", "Classic Blues Guitar", "Classical Essentials", "Classical New Releases", "Country's Greatest Hits", "Hot Country", "Country Top 50", "Fresh Folk", "Roots Rising", "jazz classics the best tunes in jazz history", "Hip-Hop Drive", "Gold School", "RAP GENERACJA", "Reggae Classics", "Summer Sunshine Reggae", "celebrating the film bob marley one love", "Reggae Party", "Rock Classics", "All New Rock", "Metal Essentials", "00s Metal Classics", "10s Metal Classics" )pb <- txtProgressBar(min = 0, max = length(playlist_names), style = 3, width = 50, char = "=") dataset <- NULLfor (p in 1:length(playlist_names)) { search_results <- search_spotify( q = playlist_names[p], type = c("playlist"), authorization = get_spotify_access_token() ) playlist_id <- search_results %>% select(id, name, uri) %>% slice_head(n = 1) %>% select(id) %>% pull() p_dt <- get_playlist_audio_features( username = "Spotify", playlist_uris = c(playlist_id), ) if (p == 1) { dataset <- p_dt } else { dataset <- rbind(dataset, p_dt) } setTxtProgressBar(pb, p)}df <- as.data.frame(dataset)df <- df[,-which(sapply(df, class) == "list")]# write.csv(df, "spotify-genres-classification.csv")# write.csv(df, "spotify-genres-classification-new.csv")```# Przetwarzanie i czyszczenie zbioru## Wstępna selekcja cechZbiór pierwotnie zawiera wiele niepotrzebnych zmiennych, takich jak identyfikator playlisty, z której pochodzi dany utwór czy link do okładki każdej piosenki.```{r echo=FALSE}file <- read.csv("spotify-genres-classification-new.csv")kable(head(file), "html") %>% kable_styling("striped") %>% scroll_box(width = "100%")```Należy się ich natychmiast pozbyć, aby zwiększyć przejrzystość zbioru oraz ułatwić dalszą analizę.```{r echo=FALSE}print_oneperline <- function(x) { cat(sprintf(paste0('% ', floor(log10(length(x))) + 3,'s "%s"\n'), paste0("[", seq_along(x), "]"), x), sep = "") }``````{r echo=FALSE}df <- file %>% select(-c("X", "playlist_id", "playlist_img", "playlist_owner_name", "playlist_owner_id", "track.id", "analysis_url", "added_at", "is_local", "primary_color", "added_by.href", "added_by.id", "added_by.type", "added_by.uri", "added_by.external_urls.spotify", "track.disc_number", "track.episode", "track.href", "track.is_local", "track.preview_url", "track.track", "track.track_number", "track.type", "track.uri", "track.album.album_type", "track.album.href", "track.album.id", "track.album.name", "track.album.total_tracks", "track.album.type", "track.album.uri", "track.album.external_urls.spotify", "track.external_ids.isrc", "track.external_urls.spotify", "video_thumbnail.url", "track.album.release_date_precision"))``````{r echo=FALSE}# print_oneperline(colnames(df))```Zestaw cech po wstępnej selekcji wyglądał zatem następująco| Cecha | Opis ||-------------------|-----------------------------------------------------|| `playlist_name` | Nazwa playlisty, z której pochodzi utwór || `danceability` | "Taneczność" utworu - zmienna ciągła przyjmująca wartości w przedziale \[0, 1\] || `energy` | Energiczność utworu - zmienna ciągła przyjmująca wartości w przedziale \[0, 1\] || `key` | Skala utworu - zmienna kategoryczna, której wartości odpowiadają notacji z <https://en.wikipedia.org/wiki/Pitch_class> || `loudness` | Głośność utworu w decybelach - zmienna ciągła przyjmująca wartości ujemne || `mode` | Zmienna kategoryczna przyjmująca wartość 1 jeśli utwór jest w skali molowej i 0 jeśli utwór jest w skali durowej || `speechiness` | "Recytowalność" tekstu utworu - zmienna ciągła przyjmująca wartości w przedziale \[0, 1\] || `acousticness` | Określa szansę na to, że utwór jest akustyczny - zmienna ciągła przyjmująca wartości w przedziale \[0, 1\] || `instrumentalness` | Określa szansę na to, że utwór nie zawiera wokalu - zmienna ciągła przyjmująca wartości w przedziale \[0, 1\] || `liveness` | Określa szansę na to, że utwór był wykonywany na żywo - zmienna ciągła przyjmująca wartości w przedziale \[0, 1\] || `valence` | Określa jak bardzo pozytywnie jest nastrojony utwór - zmienna ciągła przyjmująca wartości w przedziale \[0, 1\] || `tempo` | Zmienna ciągła określająca tempo piosenki w bpm (*beats per minute*) || `time_signature` | Zmienna kategoryczna określająca metrum muzyczne, gdzie np. wartość 3 oznacza metrum 3/4 || `track.explicit` | Zmienna kategoryczna przyjmująca wartość TRUE dla utworów z treściami wulgarnymi i FALSE dla pozostałych || `track.duration_ms` | Zmienna ciągła określająca długość trwania utworu w milisekundach || `track_name` | Identyfikator określający nazwę utworu || `track_popularity` | Popularność utworu - zmienna ciągła przyjmująca wartości w przedziale \[0, 100\] || `track.album.release_date` | Data wydania utworu || `key_name` | Zmienna kategoryczna określająca skalę, w jakiej jest utwór || `mode` | Zmienna kategoryczna określająca skalę, przyjmująca wartość `minor` lub `major` || `key_mode` | Połączenie zmiennej `key_name` i `key_mode` |: Opis zestawu cech::: callout-noteCelowo zostawiłem zmienną `track_name` będącą identyfikatorem w celu ułatwienia pracy ze zbiorem oraz interpretacji wyników.:::Patrząc na powyższy zestaw cech od razu można zauważyć, że zmienna `mode_name` jest nadmiarowa w stosunku do zmiennej `mode`, `key_name` jest nadmiarowa w stosunku do `key`, a `key_mode` będąca kombinacją poprzednich zmiennych również jest zbyteczna, od razu więc się ich pozbywam.```{r}df <- df %>%select(-c(key_mode, key_name, mode_name))```## Utworzenie zmiennej zależnej określającej gatunek utworuZmienna określająca gatunek nie była częścią zbioru cech pozyskanych bezpośrednio z API, dlatego muszę ją stworzyć samodzielnie korzystając ze zmiennej określającej nazwę playlisty, z której pochodzi utwór.```{r echo=FALSE}kable(table(df$playlist_name), "html", col.names = c("Variable", "Frequency")) %>% kable_styling("striped") %>% scroll_box(height = "500px")``````{r}df$genre <-"NA"metal_playlists <-c("00s Metal Classics", "10s Metal Classics", "Metal Essentials")hiphop_playlists <-c("Hip-Hop Drive", "Gold School", "RAP GENERACJA")rock_playlists <-c("All New Rock", "Rock Classics")blues_playlists <-c("Blues Classics", "Blues Standards", "Classic Blues Guitar", "Electric Blues Classics")classical_playlists <-c("Classical Essentials", "Classical New Releases")country_playlists <-c("Country's Greatest Hits", "Country Top 50", "Hot Country")folk_playlists <-c("Fresh Folk", "Roots Rising")jazz_playlists <-c("Jazz Classics")reggae_playlists <-c("Reggae Classics", "Summer Sunshine Reggae", "Reggae Party")df[df$playlist_name %in% metal_playlists,]$genre <-"metal"df[df$playlist_name %in% hiphop_playlists,]$genre <-"hiphop"df[df$playlist_name %in% rock_playlists,]$genre <-"rock"df[df$playlist_name %in% blues_playlists,]$genre <-"blues"df[df$playlist_name %in% classical_playlists,]$genre <-"classical"df[df$playlist_name %in% country_playlists,]$genre <-"country"df[df$playlist_name %in% folk_playlists,]$genre <-"folk"df[df$playlist_name %in% jazz_playlists,]$genre <-"jazz"df[df$playlist_name %in% reggae_playlists,]$genre <-"reggae"df <- df %>%filter(genre !="NA")```Liczba utworów przynależących do poszczególnych gatunków prezentuje się następująco. Widać, że zgodnie z zamierzeniem każdy gatunek posiada 200-300 obserwacji, dzięki czemu zbiór jest w miarę zbilansowany.```{r echo=FALSE}kable(table(df$genre), "html", col.names = c("Variable", "Frequency")) %>% kable_styling("striped")```Zmienna `genre` określająca gatunek jest już utworzona, więc mogę się pozbyć cechy `playlist_name`.```{r}df <- df %>%select(-playlist_name)```## Sprawdzenie obecności wartości brakujących```{r}any(is.na(df))``````{r echo=FALSE}kable(df[!complete.cases(df), ], "html") %>% kable_styling("striped")```Wygląda na to, że zbiór posiada wartości brakujące. Po ich wyświetleniu okazuje się jednak, że stanowi je pojedynczy wiersz, w którym wartości każdej zmiennej z wyjątkiem `genre` to NA. Jest to prawdopodobnie spowodowane tym, że API pobrało z playlisty muzyki klasycznej utwór, który nie jest już dostępny w serwisie. Z racji tego, że jest to jedyny wiersz z wybrakowanymi wartościami w całym zbiorze, śmiało można się go pozbyć bez konieczności imputacji danych.```{r}df <-na.omit(df)any(is.na(df))```## Sprawdzenie obecności duplikatówNa obserwacje z poszczególnych gatunków przeważnie składają się utwory z kilku różnych playlist. Nie jest jednak wykluczone, że w kilku playlistach dla jednego gatunku pewna piosenka się powtórzyła. Należy więc wybadać, czy taka sytuacja wystąpiła i mamy do czynienia z duplikatami.```{r echo=FALSE}duplicated(df$track.name) %>% table() %>% kable("html", table.attr = "style='width:30%;'", col.names = c("Is duplicated", "Frequency")) %>% kable_styling("striped")```Okazuje się, że w naszym zbiorze powtórzyły się aż 162 utwory. Należy usunąć ich powtórzenia, aby wyeliminować problem identycznych obserwacji.```{r}df <- df %>%distinct(track.name, .keep_all =TRUE)```Po zostawieniu w zbiorze wierszy o unikalnych tytułach problem zostaje rozwiązany.```{r echo=FALSE}duplicated(df$track.name) %>% table() %>% kable("html", table.attr = "style='width:30%;'", col.names = c("Is duplicated", "Frequency")) %>% kable_styling("striped") ```## Weryfikacja poprawności danych```{r echo=FALSE}devtools::source_gist('4a0a5ab9fe7e1cf3be0e')strtable(df) %>% select(-levels) %>% kable("html") %>% kable_styling("striped") %>% scroll_box(height = "500px")```Wyświetlenie typu i przykładowych obserwacji dla każdej zmiennej od razu pozwala nam zauważyć parę problemów ze zbiorem, którymi trzeba będzie się zająć. Są to między innymi niepoprawne typy danych lub to, że zmienna przedstawiająca datę, w której opublikowany został utwór, czasami jest wyrażona w dokładności co do nia, a czasami co do roku wydania.```{r echo=FALSE}funs <- lst(min, median, mean, max, sd)df$track.popularity <- as.numeric(df$track.popularity)t(map_dfr(funs, ~ summarize(df %>% select(-c(key, mode, time_signature)), across(where(is.numeric), .x, na.rm = TRUE)), .id = "statistic")) %>% kable("html") %>% kable_styling("striped")```Statystyki opisowe dla zmiennych ciągłych również obrazują kilka anomalii w naszym zbiorze. Widać na przykład, że istnieje utwór z tempem równym 0, co jest niemożliwe. Istnieją również obserwacje o zerowej popularności, co jest mało prawdopodobne biorąc pod uwagę to, że praktycznie każda playlista, z której zostały pobrane dane, zawierała klasyki gatunku.### Usunięcie wiersza z zerowym tempem```{r}#| code-fold: truedf[df$tempo <40, ] %>%kable("html") %>%kable_styling("striped")```Po wyświetleniu wszystkich wierszy, które mają tempo poniżej 40 bpm okazuje się, że jest tylko jeden taki utwór. Można go więc usunąć bez obawy, że spowoduje to wielką różnicę w zbiorze.```{r}df <- df %>%filter(tempo !=0)```### Rezygnacja ze zmiennej `popularity````{r}#| code-fold: trueggplot(df, aes(x = track.popularity)) +geom_histogram(bins =30, fill ="#F8766D") +labs(title ="Histogram zmiennej `popularity`")```Wyświetlenie histogramu zmiennej `popularity` pozwala zauważyć, że mamy bardzo dużo zmiennych o popularności równej lub bliskiej 0. Sprawdźmy, ile wierszy ma "zerową" popularność.```{r}nrow(df[df$track.popularity ==0, ])``````{r}tail(df[df$track.popularity ==0, ]$track.name, 10)```Mamy aż 245 takich wierszy. Można więc wywnioskować, że zmienna ta nie jest definiowana przez serwis Spotify w prawidłowy sposób i zawiera nieprawdopodobne wartości, co potwierdza fakt, że utwór Slayera "Repentless" również ma "zerową" popularność. Sprawiło to, że skłoniłem się do wyeliminowania jej ze zbioru danych.```{r}df <- df %>%select(-track.popularity)```### Zmiana wartości w zmiennej `time_signature````{r echo=FALSE}table(df$time_signature) %>% kable("html", table.attr = "style='width:30%;'", col.names = c("Value", "Frequency")) %>% kable_styling("striped")```Po wyświetleniu liczby utworów w poszczególnym metrum okazuje się, że zbiór jest zdominowany przez utwory w najpopularniejszym metrum, jakim jest 4/4. Podejrzane jest jednak to, że mamy 18 utworów w metrum 1/4, co najprawdopodobniej jest błędem. Przyjrzyjmy się utworom w tym metrum, żeby zidentyfikować źródło takiego kodowania.```{r}tail(df[df$time_signature ==1, ]$track.name)```Okazuje się, że metrum to jest przypisane albo utworom, które posiadają "płynną" strukturę, charakterystyczną dla muzyki klasycznej, albo tym, które cechuje niestabilne metrum, czyli takie, które często zmienia się w trakcie trwania utworu. Dobrym przykładem jest utwór "Money" zespołu Pink Floyd, (motyw główny jest w 7/4, ale potem podczas "mostku" przechodzi w 4/4, a następnie 6/4, żeby potem znowu wrócić do 7/4), lub utwór "Pneuma" zespołu Tool, gdzie metrum zmienia się praktycznie cały czas (posiada on nawet powtarzający się segment, w którym mamy do czynienia z następującymi po sobie taktami w metrum 33/16, 33/16, 28/16 i 30/16!).{{< video https://www.youtube.com/watch?v=5ClCaPmAA7s >}}Nic więc dziwnego, że API przy wyodrębnianiu metrum z tych piosenek "zwariowało" i sklasyfikowało je jako 1/4, co oznaczałoby mniej więcej, że na cały takt przypada jedno uderzenie, co oczywiście jest w tym przypadku nieprawdą. Z racji tego, że utwory w metrum 3/4 i 5/4, zarówno jak "utwory w metrum 1/4" występują stosunkowo rzadko, postanowiłem złączyć je w jedną kategorię "other", natomiast utwory w metrum 4/4 pozostawić niezmienione.```{r echo}df$time_signature <- as.character(df$time_signature)df$time_signature <- ifelse(df$time_signature == "4", df$time_signature, "other")```Tak prezentuje się teraz podział utworów ze względu na obecność metrum 4/4 lub innego.```{r echo=FALSE}table(df$time_signature) %>% kable("html", table.attr = "style='width:30%;'", col.names = c("Value", "Frequency")) %>% kable_styling("striped")```### Zmiana zmiennej `track.album.release_date` na zmienną `decade`Z poprzednich wnioskowań wyniknęło, że zmienna przedstawiająca datę wydania utworu jest wyrażana z różną dokładnością, ponieważ czasem mamy w niej podaną całą datę, a czasami tylko rok. W celu ujednolicenia dokładności wartości tej cechy zdecydowałem się z każdej daty wyciągnąć tylko rok. Następnie uznałem, że dobrym pomysłem byłoby przypisanie każdego roku do poszczególnej dekady, w celu skategoryzowania zmiennej. Piosenek z lat 30', 40', 50' i 60' było mało, więc zgrupowałem je do jednej kategorii "other", żeby grupy były bardziej zbilansowane.```{r}df$track.album.release_date <-substr(df$track.album.release_date, 1, 4)colnames(df)[16] <-"release_year"df$release_year <-as.integer(df$release_year)df$decade <- (df$release_year - df$release_year %%10) %%100df[df$decade %in%c(30, 40, 50, 60), ]$decade <-"<70"df[df$decade ==0, ]$decade <-"00"df$decade <-as.character(df$decade)df$decade <-factor(df$decade, levels =c("<70", "70", "80", "90", "00", "10", "20"))df <- df %>%select(-release_year)```W ten sposób utworzyłem kategoryczną zmienną `decade`, która przyjmuję następujące wartości z daną częstotliwością:```{r echo=FALSE}table(df$decade) %>% kable("html", table.attr = "style='width:30%;'", col.names = c("Value", "Frequency")) %>% kable_styling("striped")```### Zmiana wartości w kolumnie `track.explicit` na 0 i 1Zmienna `track.explicit` przyjmuje wartości logiczne TRUE lub FALSE, dlatego decyduję się je zamienić na wartości 1 i 0, gdzie 1 odpowiada utworowi, który zawiera treści wulgarne.```{r}df$track.explicit <-ifelse(df$track.explicit ==TRUE, 1, 0)```Rozkład tej cechy przedstawia się następująco:```{r echo=FALSE}table(df$track.explicit) %>% kable("html", table.attr = "style='width:30%;'", col.names = c("Value", "Frequency")) %>% kable_styling("striped")```### Kodowanie zmiennych kategorycznychZmienne, które przyjmują ograniczoną liczbę wartości, odpowiednio zakodowałem jako zmienne kategoryczne.```{r}df$genre <-factor(df$genre)df$key <-factor(df$key)df$mode <-factor(df$mode)df$time_signature <-factor(df$time_signature)df$track.explicit <-factor(df$track.explicit)```### Zmiana jednostki w zmiennej `track.duration_ms` na sekundyZmienna `track.duration_ms` jest wyrażana w milisekundach, przez co przyjmuje bardzo duże wartości. W celu ułatwienia interpretacji tej cechy zamienię jednostkę na sekundy dzieląc jej wartości przez 1000.```{r}df$track.duration_s <- df$track.duration_ms /1000df <- df %>%select(-track.duration_ms)```## Efekt przetwarzania i czyszczenia zbioru```{r echo=FALSE}devtools::source_gist('4a0a5ab9fe7e1cf3be0e')strtable(df) %>% select(-levels) %>% kable("html") %>% kable_styling("striped") %>% scroll_box(height = "500px")```Widać, że wszystkie zmienne mają teraz poprawnie zakodowane typy.```{r echo=FALSE}funs <- lst(min, median, mean, max, sd)t(map_dfr(funs, ~ summarize(df %>% select(-c(key, mode, time_signature)), across(where(is.numeric), .x, na.rm = TRUE)), .id = "statistic")) %>% kable("html") %>% kable_styling("striped")```Statystyki opisowe również nie wykazują żadnych nieprawidłowości w zbiorze.# Wizualizacja zależności pomiędzy zmiennymi## Rozkłady zmiennych::: panel-tabset#### Zmienne kategoryczne::: panel-tabset##### Gatunek```{r echo=FALSE}ggplot(df, aes(x = genre)) + geom_bar(fill = "#F8766D")```##### Skale```{r echo=FALSE}ggplot(df, aes(x = key, fill = mode)) + geom_bar()```##### Metrum```{r echo=FALSE}ggplot(df, aes(x = time_signature, fill = genre)) + geom_bar()```##### Wulgarność```{r echo=FALSE}ggplot(df, aes(x = track.explicit, fill = genre)) + geom_bar()```##### Dekada```{r echo=FALSE}ggplot(df, aes(x = decade, fill = genre)) + geom_bar()```:::#### Zmienne ciągłe::: panel-tabset##### Taneczność```{r echo=FALSE}ggplot(df, aes(x = danceability)) + geom_histogram(bins = 30, fill = "#F8766D")```##### Energiczność```{r echo=FALSE}ggplot(df, aes(x = energy)) + geom_histogram(bins = 30, fill = "#F8766D")```##### Głośność```{r echo=FALSE}ggplot(df, aes(x = loudness)) + geom_histogram(bins = 30, fill = "#F8766D")```##### Recytowalność```{r echo=FALSE}ggplot(df, aes(x = speechiness)) + geom_histogram(bins = 30, fill = "#F8766D")```##### Akustyczność```{r echo=FALSE}ggplot(df, aes(x = acousticness)) + geom_histogram(bins = 30, fill = "#F8766D")```##### Instrumentalność```{r echo=FALSE}ggplot(df, aes(x = instrumentalness)) + geom_histogram(bins = 30, fill = "#F8766D")```##### Wersja na żywo```{r echo=FALSE}ggplot(df, aes(x = liveness)) + geom_histogram(bins = 30, fill = "#F8766D")```##### Pozytywność nastroju```{r echo=FALSE}ggplot(df, aes(x = valence)) + geom_histogram(bins = 30, fill = "#F8766D")```##### Tempo```{r echo=FALSE}ggplot(df, aes(x = tempo)) + geom_histogram(bins = 30, fill = "#F8766D")```##### Długość trwania```{r echo=FALSE}ggplot(df, aes(x = track.duration_s)) + geom_histogram(bins = 30, fill = "#F8766D")```::::::::: callout-importantWyraźnie widać, że część zmiennych ciągłych ma rozkłady asymetryczne gruboogonowe. Oznacza to, że niektóre modele uczenia maszynowego będą wymagały uprzedniej transformacji tych zmiennych w celu wymuszenia większej symetryczności rozkładów.:::### Rozkłady dekad w zależności od gatunku```{r echo=FALSE}genres <- unique(df$genre)```::: panel-tabset##### Blues```{r echo=FALSE}ggplot(df[df$genre == genres[1], ], aes(x = decade)) + geom_bar(fill = "#F8766D") + labs(title = str_to_sentence(paste0(genres[1], " w dekadach"))) + theme(plot.title = element_text(hjust = 0.5, size = 25))```##### Muzyka klasyczna```{r echo=FALSE}ggplot(df[df$genre == genres[2], ], aes(x = decade)) + geom_bar(fill = "#F8766D") + labs(title = str_to_sentence(paste0(genres[2], " w dekadach"))) + theme(plot.title = element_text(hjust = 0.5, size = 25))```##### Country```{r echo=FALSE}ggplot(df[df$genre == genres[3], ], aes(x = decade)) + geom_bar(fill = "#F8766D") + labs(title = str_to_sentence(paste0(genres[3], " w dekadach"))) + theme(plot.title = element_text(hjust = 0.5, size = 25))```##### Folk```{r echo=FALSE}ggplot(df[df$genre == genres[4], ], aes(x = decade)) + geom_bar(fill = "#F8766D") + labs(title = str_to_sentence(paste0(genres[4], " w dekadach"))) + theme(plot.title = element_text(hjust = 0.5, size = 25))```##### Jazz```{r echo=FALSE}ggplot(df[df$genre == genres[5], ], aes(x = decade)) + geom_bar(fill = "#F8766D") + labs(title = str_to_sentence(paste0(genres[5], " w dekadach"))) + theme(plot.title = element_text(hjust = 0.5, size = 25))```##### Hiphop```{r echo=FALSE}ggplot(df[df$genre == genres[6], ], aes(x = decade)) + geom_bar(fill = "#F8766D") + labs(title = str_to_sentence(paste0(genres[6], " w dekadach"))) + theme(plot.title = element_text(hjust = 0.5, size = 25))```##### Reggae```{r echo=FALSE}ggplot(df[df$genre == genres[7], ], aes(x = decade)) + geom_bar(fill = "#F8766D") + labs(title = str_to_sentence(paste0(genres[7], " w dekadach"))) + theme(plot.title = element_text(hjust = 0.5, size = 25))```##### Rock```{r echo=FALSE}ggplot(df[df$genre == genres[8], ], aes(x = decade)) + geom_bar(fill = "#F8766D") + labs(title = str_to_sentence(paste0(genres[8], " w dekadach"))) + theme(plot.title = element_text(hjust = 0.5, size = 25))```##### Metal```{r echo=FALSE}ggplot(df[df$genre == genres[9], ], aes(x = decade)) + geom_bar(fill = "#F8766D") + labs(title = str_to_sentence(paste0(genres[9], " w dekadach"))) + theme(plot.title = element_text(hjust = 0.5, size = 25))```:::Można tu zauważyć kilka ciekawych faktów. Utwory bluesowe i jazzowe w zdecydowanej większości powstały dawniej niż w latach 70', z kolei najwięcej utworów klasycznych w zbiorze pochodzi z lat 20'. Innym ciekawym spostrzeżeniem jest to, że prawie wszystkie piosenki folkowe pochodzą z lat 20' -jedynie garstka z nich pochodzi jeszcze z lat 10'. Warto zaznaczyć, że rozkłady te nie odzwierciedlają jednak faktycznego procesu popularyzacji tych gatunków - odzwierciedlają one jedynie charakterystykę zbioru.## Treści wulgarne w zależności od gatunku```{r echo=FALSE}df %>% filter(track.explicit == 1) %>% group_by(genre) %>% summarise(n = n()) %>% mutate(all = c(187, 226, 238, 248, 235, 247)) %>% ggplot(aes(x = reorder(genre, -n), y = n)) + geom_bar(stat = "identity", fill = "#F8766D") + xlab("genre") + ylab("count") + geom_text(aes(label = paste0(round(n/all*100, 2), "%")), position = position_dodge(width = 0.9), vjust = -0.25) + labs(caption = "Wartości na słupkach oznaczają stosunek utworów wulgarnych do wszystkich utworów w danym gatunku") + theme(plot.caption = element_text(hjust = 0))```Nie jest zaskoczeniem fakt, że najwięcej utworów zawierających treści wulgarne pochodzi z gatunku hip-hop (zawiera je aż prawie 90% utworów z tego gatunku). Kolejny w kolejność jest metal, ponad 1/5 gatunków z tego utworu zawiera treści nieodpowiednie dla młodych odbiorców. Jedynymi gatunkami, w których nie ma żadnych wulgaryzmów są blues, muzyka klasyczna i jazz.## Energiczność vs. głośność utworów```{r echo=FALSE}ggplot(df, aes(x = energy, y = loudness, colour = genre)) + geom_point()```Energiczność i głośność utworu to cechy, które z pozoru wydają się być powiązane. Powyższy wykres dowodzi, że rzeczywiście występuje taka zależność: im utwór jest bardziej energiczny, tym głośniejszy. Dodatkowo można zauważyć, że niektóre gatunki układają się w "chmury" - utwory metalowe są na przykład ściśle skupione w prawym górnym rogu wykresu, co oznacza że są jednocześnie najgłośniejsze i najbardziej energiczne.## Taneczność vs. pozytywność utworów```{r echo=FALSE}ggplot(df, aes(x = danceability, y = valence, colour = genre)) + geom_point()```Czy to, że utwór jest bardziej pozytywny oznacza, że nadaje się również bardziej do tańczenia? Powyższy wykres pokazuje, że jest w tym trochę prawdy.Można zauważywć, że najbardziej tanecznym i pozytywnym gatunkiem jest reggae. Może natomiast dziwić fakt, iż wygląda na to, że wiele utworów klasycznych ze zbioru nie nadaje się do tańca i jest wyjątkowo depresyjna.## Głośność utworów na przestrzeni lat```{r echo=FALSE}df %>% group_by(decade, genre) %>% summarise(loudness = round(mean(loudness), 2)) %>% ungroup() %>% plot_ly(x = ~decade, y = ~loudness, color = ~genre, hovertext = ~loudness) %>% add_lines(hovertemplate = paste0("Average loudness: %{hovertext}"))```Ciekawym faktem jest to, że w więkoszści gatunków utwory stawały się głośniejsze z dekady na dekadę. Nie jest zaskoczeniem, że metal prawie zawsze był najgłośniejszym gatunkiem, w tym latach 20' charakteryzował się średnią głośnością na poziomie -3.23 dB, co znacznie kontrastuje z -23.68 dB w przypadku muzyki klasycznej z tej samej dekady.## Rozkłady czasu trwania utworów```{r echo=FALSE}ggplot(df, aes(y = track.duration_s, x = genre, fill = genre)) + geom_boxplot() + theme(legend.position = "") + ylab("Track length")```Patrząc na wykresy pudełkowe długości piosenek w zależności od gatunku można podejrzewać, że jest szansa na to, że niektóre rozkłady są do siebie zbliżone. Żeby sprawdzić to sprawdzić zamierzam przeprowadzić test ANOVA. Najpierw sprawdzam jednak wymagane założenia o wielowymiarowej normalności w grupach, zbalansowaniu grup i ich homogeniczności.```{r echo=FALSE}df %>%group_by(genre) %>%summarise(sample_size=length(track.duration_s), p_value=shapiro.test(track.duration_s)$p.value) %>% kable("html") %>% kable_styling("striped")``````{r}bartlett.test(df$track.duration_s, df$genre)```Wygląda na to, że żadne z założeń nie jest spełnione, zatem przeprowadzę test Kruskala-Wallisa będący parametrycznym odpowiednikiem testu ANOVA.```{r}kruskal.test(track.duration_s ~ genre, df)```Test ten wykazał, że należy odrzucić hipotezę mówiącą o tym, że średnie długości piosenek w gatunkach są na takim samym poziomie. Żeby sprawdzić, które z nich się różnią pomiędzy sobą, przeprowadzę test Dunna.```{r}dunntest <-dunn_test(df, track.duration_s ~ genre, p.adjust.method ="bonferroni")``````{r echo=FALSE}dunntest <- dunntest %>% add_xy_position(x = "genre")ggboxplot(df, x = "genre", y = "track.duration_s", fill = "genre", legend = "") + stat_pvalue_manual(dunntest, hide.ns = TRUE, label = "{round(p.adj, 2)}") ```Na wykresie pokazane są tylko wartości p-value, które były istotne statystycznie. Okazuje się więc, że przypuszczenia były niesłuszne - w każdym przypadku należy odrzucić hipotezę, że średnia długość utworów jest taka sama.## Zależność zmiennej `valence` od skaliW praktyce, skale molowe i oparte na nich utwory muzyczne uważa się za mające smutne brzmienie, w odróżnieniu od skal durowych, które uważa się za radosne.```{r echo=FALSE}ggplot(df, aes(x = mode, y = valence)) + geom_boxplot()```Patrząc na wykresy pudełkowe wygląda na to, że zmienna `valence` nie zależy od tego, czy utwór jest w skali durowej czy molowej. Potwierdzenie otrzymamy przeprowadzając test t-Studenta wcześniej sprawdzając założenie o jednorodności wariancji.```{r}var.test(df[df$mode ==0, ]$valence, df[df$mode ==1, ]$valence)```Założenie o jednorodności wariancji jest spełnione. Przeprowadzę zatem t-test.```{r}t.test(valence ~ mode, data = df)```P-wartość przeprowadzonego testu wskazuje na to, że nie ma podstaw do odrzucenia hipotezy o równości średnich w tych rozkładach. Oznacza to, że w naszym zbiorze cecha mówiąca o nastroju utworu nie zależy od skali, co jest dosyć ciekawym wnioskiem.## Korelacja pomiędzy zmiennymi niezależnymi ciągłymi```{r echo=FALSE}df %>% select(where(is.numeric)) %>% cor(method = "spearman") %>% corPlot(cex = 0.5, xlas = 2)```Wysoka korelacja między zmiennymi zależnymi może stanowić problem podczas budowy modelu uczenia maszynowego. Wiele źródeł podaje, że można ją uznać za wysoką, jeżeli przekracza wartość 0.7. W naszym zbiorze wartość ta jest przekraczana trzy razy, pomiędzy parami zmiennych:- energy-loudness,- energy-acousticness,- loudness-acousticness.::: panel-tabset#### energy vs. loudness```{r echo=FALSE}ggplot(df, aes(x = energy, y = loudness)) + geom_point() + geom_smooth(method = "lm", formula = y ~ poly(x, 2, raw = TRUE))```#### energy vs. acousticness```{r echo=FALSE}ggplot(df, aes(x = acousticness, y = energy)) + geom_point() + geom_smooth(method = "lm")```#### loudness vs. acousticness```{r echo=FALSE}ggplot(df, aes(x = acousticness, y = loudness)) + geom_point() + geom_smooth(method = "lm", formula = y ~ poly(x, 2, raw = TRUE))```:::Na wykresach pomiędzy tymi zmiennymi widoczne są odkryte zależności, szczególnie w przypadku energiczności i głośności, gdzie obserwacje dosyć wyraźnie układają się wzdłuż krzywej dopasowanej wielomianem drugiego stopnia.```{r echo=FALSE}fig <- plot_ly(df, x = ~energy, y = ~loudness, z = ~acousticness, color = ~genre)fig <- fig %>% add_markers(size = 0.8)fig <- fig %>% layout(scene = list(xaxis = list(title = 'energy'), yaxis = list(title = 'loudness'), zaxis = list(title = 'acousticness')))fig```Na wykresie trójwymiarowym widać jak bardzo te trzy cechy zależą od siebie nawzajem.Żeby sprawdzić, czy możliwe będzie zastąpienie tych trzech zmiennych tylko jedną z nich, przeprowadzę PCA w celu sprowadzenia trzech wymiarów do jednego, a następnie sprawdzę, która z tych zmiennych ma największy wpływ na kształtowanie tego wymiaru.```{r echo=FALSE}library(factoextra)enloac <- df %>% select(energy, loudness, acousticness)pca <- prcomp(enloac, center = TRUE, scale. = TRUE)fviz_eig(pca)```Z powyższego wykresu widać, że pierwszy wymiar wyjaśnia aż około 80% zmienności.```{r echo=FALSE}fviz_contrib(pca, choice = "var", axes = 1)```Wykres wkładu zmiennych w pierwszy wymiar oraz linia odcięcia sugerują, że zmienna `energy` ma istotnie największy wpływ w tworzenie się tego wymiaru. Na tej podstawie decyduję się więc na to, żeby z trójki zmiennych `energy`, `acousticness` i `loudness` zostawić tylko `energy`, co wyeliminuje problem wysokiej korelacji pomiędzy częścią zmiennych zależnych w zbiorze. Później sprawdzę też jednak jak będzie się spisywał model zbudowany na zbiorze z usuniętymi zmiennymi. "Wysoka korelacja" jest bowiem płynnym określeniem - niektóre źródła podają, że zaczyna się ona już od wartości 0.7, natomiast czasami o wysokiej korelacji mówi się dopiero od 0.9. Jest zatem szansa, że korelacja, która tu wystąpiła, nie miała wpływu na przyszłą skuteczność predykcyjną modelu.```{r}df2 <- dfdf <- df %>%select(-c(loudness, acousticness))```# Przygotowanie modelu klasyfikacji wieloklasowejZbiór nie zawiera już braków danych ani duplikatów, wyselekcjonowane zmienne są poprawnie zakodowane i nie zawierają nieprawidłowości, a problem wysokiej korelacji został wyeliminowany, dlatego można przejść do przygotowywania modelu przewidującego gatunek muzyczny.## Podział zbioru na treningowy i testowyZbiór dzielę na treningowy i testowy w proporcjach 0.8 ustawiając parametr `strata` o wartości "genre", aby proporcje liczebności grup zmiennej objaśnianej były zachowane.```{r}set.seed(170)split <-initial_split(df, prop =0.8, strata = genre)spotify_train <-training(split)spotify_test <-testing(split)split```## Przygotowanie 10-krotnej walidacji krzyżowej```{r}spotify_folds <-vfold_cv(spotify_train, strata = genre)spotify_folds```## Przygotowanie odpowiednich recepturW poniższej tabeli przedstawione są modele, których zamierzam użyć do budowy modeli predykcyjnych na podstawie mojego zbioru, a także rodzaj preprocessingu, który jest dla nich rekomendowany.| Model | Dummy vars | Zero-variance | Decorrelate | Normalize | Transform ||---------------|------------|---------------|-------------|-----------|-----------|| Random forest | ✗ | ◌ | ◌ | ✗ | ✗ || Bagging | ✗ | ✗ | ◌ | ✗ | ✗ || XGBoost | ✗ | ◌ | ◌ | ✗ | ✗ || Mlp | ✓ | ✓ | ✓ | ✓ | ✓ || Knn | ✓ | ✓ | ◌ | ✓ | ✓ || Svm | ✓ | ✓ | ✓ | ✓ | ✓ || Naive Bayes | ✗ | ✓ | ◌ | ✗ | ✗ |Rodzaje preprocessingu:- dummy vars - przekształcenie zmiennych kategorycznych w tzw. dummy variables- zero-variance - usunięcie zmiennych, które zawierają tylko pojedynczą wartość- decorrelate - wyeliminowanie problemu wysokiej korelacji pomiędzy zmiennymi objaśniającymi- normalize - normalizacja- transform - wymuszenie większej symetryczności rozkładu asymetrczynych zmiennych✓ oznacza, że dany rodzaj preprocessingu jest rekomendowany, ✗ - przeciwnie, natomiast ◌ oznacza, że dany zabieg może, ale nie musi pomóc w osiągnięciu lepszych wyników.Zbiór po uprzednich zabiegach nie posiada już problemu wysokiej korelacji, więc nie wymaga dalszej "dekorelacji". Nie zawiera on także zmiennych zawierających pojedynczą wartość, jednak zawrę ten element preprocessingu w recepturach na wszelki wypadek.Na podstawie tabelki tworzę trzy receptury:- `basic_recipe` określa predyktory i zmienną objaśnianą w modelu, nadaje zmiennej `track_name` inną rolę, żeby nie mogła być używana przy trenowaniu modelu, a także usuwane są potencjalne zmienne przyjmujące pojedynczą wartość,- `dummy_recipe` dodatkowo zamienia zmienne kategoryczne na dummy variables,- `transform_recipe` dodatkowo transformuje zmienne numeryczne za pomocą transformacji Yeo-Johnsona i je normalizuje.```{r}basic_recipe <-recipe(genre~., data = spotify_train) %>%update_role(track.name, new_role ="track_name") %>%step_zv(all_predictors())``````{r echo=FALSE}basic_recipe %>% summary()``````{r}dummy_recipe <- basic_recipe %>%step_dummy(all_nominal_predictors())``````{r}transform_recipe <- dummy_recipe %>%step_YeoJohnson(all_numeric_predictors()) %>%step_normalize(all_numeric_predictors())``````{r echo=FALSE}summary(transform_recipe %>% prep(spotify_train)) %>% kable("html") %>% kable_styling("striped") %>% scroll_box(height = "500px")```## Zdefiniowanie modeli i przygotowanie ich parametrów do tuningu```{r}rf_spec <-rand_forest(mtry =tune(), min_n =tune(), trees =tune()) %>%set_engine("ranger") %>%set_mode("classification")bagging_spec <-bag_tree(min_n =tune(), tree_depth =tune()) %>%set_engine("rpart", times =60L) %>%set_mode("classification")xgb_spec <-boost_tree(mtry =tune(), trees =tune(), min_n =tune(), tree_depth =tune(), learn_rate =tune(), loss_reduction =tune(), sample_size =tune()) %>%set_engine("xgboost") %>%set_mode("classification")mlp_spec <-mlp(hidden_units =tune(), penalty =tune(), epochs =tune()) %>%set_engine("nnet", trace =0) %>%set_mode("classification")nearest_spec <-nearest_neighbor(neighbors =tune(), weight_func =tune(), dist_power =tune()) %>%set_engine("kknn") %>%set_mode("classification")svm_spec <-svm_rbf(cost =tune(), rbf_sigma =tune()) %>%set_engine("kernlab") %>%set_mode("classification")bayes_spec <-naive_Bayes(smoothness =tune(), Laplace =tune()) %>%set_engine("klaR") %>%set_mode("classification")```## Przypisanie modeli do odpowiednich przepływów pracy```{r}basic <-workflow_set(preproc =list(basic = basic_recipe), models =list(random_forest = rf_spec, bagging = bagging_spec, naive_bayes = bayes_spec))``````{r}dummy <-workflow_set(preproc =list(dummy = dummy_recipe), models =list(xgb = xgb_spec))``````{r}transform <-workflow_set(preproc =list(transform = transform_recipe), models =list(mlp = mlp_spec, knn = nearest_spec, svm = svm_spec))``````{r}all_workflows <-bind_rows(basic, dummy, transform) %>%mutate(wflow_id =gsub("(basic_)|(dummy_)|(transform_)", "", wflow_id))``````{r echo=FALSE}all_workflows```## Tuning hiperparametrów modeliPrzy poszukiwaniu najlepszego modelu o najlepszych parametrach będę posługiwał się następującymi metrykami: accuracy, bal_accuracy, precision, recall, sensitivity oraz specificity.```{r}grid_ctrl <-control_grid(save_pred =TRUE,parallel_over ="everything",save_workflow =TRUE)``````{r eval=FALSE}cl <- makePSOCKcluster(4)registerDoParallel(cl)grid_results <- all_workflows %>% workflow_map(seed = 170, resamples = spotify_folds, grid = 25, metrics = metric_set(accuracy, bal_accuracy, precision, recall, sensitivity, specificity), control = grid_ctrl)stopCluster(cl)registerDoSEQ()``````{r echo=FALSE}# saveRDS(grid_results, "grid_results_tune_changed_ts.rds")grid_results <- readRDS("grid_results_tune_changed_ts.rds")```Za pomocą tych samych kroków znajdę również najlepsze parametry dla zbioru, w którym nie usunąłem zmiennych `loudness` i `acousticness`. Podział danych na zbiór treningowy i testowy będzie taki sam, dzięki czemu będzie można zobaczyć gdzie modele się różniły.```{r}#| code-fold: trueset.seed(170)split2 <-initial_split(df2, prop =0.8, strata = genre)spotify_train2 <-training(split2)spotify_test2 <-testing(split2)spotify_folds2 <-vfold_cv(spotify_train2, strata = genre)basic_recipe2 <-recipe(genre~., data = spotify_train2) %>%update_role(track.name, new_role ="track_name") %>%step_zv(all_predictors())dummy_recipe2 <- basic_recipe2 %>%step_dummy(all_nominal_predictors())transform_recipe2 <- dummy_recipe2 %>%step_YeoJohnson(all_numeric_predictors()) %>%step_normalize(all_numeric_predictors())basic2 <-workflow_set(preproc =list(basic = basic_recipe2), models =list(random_forest = rf_spec, bagging = bagging_spec, naive_bayes = bayes_spec))dummy2 <-workflow_set(preproc =list(dummy = dummy_recipe2), models =list(xgb = xgb_spec))transform2 <-workflow_set(preproc =list(transform = transform_recipe2), models =list(mlp = mlp_spec, knn = nearest_spec, svm = svm_spec))all_workflows2 <-bind_rows(basic2, dummy2, transform2) %>%mutate(wflow_id =gsub("(basic_)|(dummy_)|(transform_)", "", wflow_id))``````{r eval=FALSE}#| code-fold: truegrid_ctrl2 <- control_grid( save_pred = TRUE, parallel_over = "everything", save_workflow = TRUE)cl <- makePSOCKcluster(4)registerDoParallel(cl)grid_results2 <- all_workflows2 %>% workflow_map(seed = 170, resamples = spotify_folds2, grid = 25, metrics = metric_set(accuracy, bal_accuracy, precision, recall, sensitivity, specificity), control = grid_ctrl2)stopCluster(cl)registerDoSEQ()``````{r echo=FALSE}# saveRDS(grid_results2, "grid_results_tune_wla.rds")grid_results2 <- readRDS("grid_results_tune_wla.rds")```## Wyniki tuningu::: columns::: column**Zbiór bez `loudness` i `acousticness`**```{r echo=FALSE}grid_results %>% rank_results() %>% select(wflow_id, .metric, mean) %>% head()```:::::: column**Zbiór z tymi zmiennymi**```{r echo=FALSE}grid_results2 %>% rank_results()%>% select(wflow_id, .metric, mean) %>% head()```::::::Wygląda na to, że według każdej metryki model lasu losowego okazuje się być najlepszy. Dodatkowo wygląda na to, że model zbudowany na podstawie zbioru ze zmiennymi `loudness` i `acousticness` jest minimalnie lepszy.::: columns::: column**Zbiór bez `loudness` i `acousticness`**```{r echo=FALSE}grid_results %>% rank_results() %>% filter(.metric == "bal_accuracy") %>% select(wflow_id, bal_accuracy = mean) %>% head()```:::::: column**Zbiór z tymi zmiennymi**```{r echo=FALSE}grid_results2 %>% rank_results() %>% filter(.metric == "bal_accuracy") %>% select(wflow_id, bal_accuracy = mean) %>% head() ```::::::Po przyjęciu za główną metrykę porównawczą `balanced accuracy` model lasu losowego również zdecydowanie wygląda na najlepszy. Można więc na tym etapie przypuszczać, że wyeliminowanie korelacji nie wpłynęło na poprawę modelu, a nawet nieznacznie ją pogorszyło, co jednak zweryfikuję później ostatecznie sprawdzając dokładność każdego z modeli na zbiorze testowym.```{r echo=FALSE}#| layout-ncol: 2#| column: pageautoplot( grid_results, rank_metric = "bal_accuracy", metric = "bal_accuracy", select_best = TRUE) + geom_text(aes(y = mean - 0.03, label = wflow_id), angle = 90, hjust = 1) +lims(y = c(0.6, 0.9)) +theme(legend.position = "none") + labs(title = "Zbiór bez `loudness` i `acousticness`")autoplot( grid_results2, rank_metric = "bal_accuracy", metric = "bal_accuracy", select_best = TRUE) + geom_text(aes(y = mean - 0.03, label = wflow_id), angle = 90, hjust = 1) +lims(y = c(0.6, 0.9)) +theme(legend.position = "none") + labs(title = "Zbiór z tymi zmiennymi")```::: columns::: column```{r echo=FALSE}grid_results %>% rank_results() %>% filter(.metric == "bal_accuracy") %>% select(model, bal_accuracy = mean) %>% group_by(model) %>% arrange(-bal_accuracy) %>% slice(1) %>% ungroup() %>% arrange(-bal_accuracy)```:::::: column```{r echo=FALSE}grid_results2 %>% rank_results() %>% filter(.metric == "bal_accuracy") %>% select(model, bal_accuracy = mean) %>% group_by(model) %>% arrange(-bal_accuracy) %>% slice(1) %>% ungroup() %>% arrange(-bal_accuracy)```::::::Zestawienie najlepszego modelu każdego rodzaju potwierdza, że w naszym zadaniu najlepiej poradziły sobie modele "drzewiaste", najsłabiej natomiast spisał się naiwny klasyfikator bayesowski. Dodatkowo wyraźnie widać, że zostawienie usuniętych wcześniej zmiennych poprawiło średnią dokładność każdego z modeli. Kolejność modeli w zależności od użytego zbioru od najdokładniejszego do najmniej dokładnego również jest podobna - jedynie model svm okazał się minimalnie lepszy i wyprzedził model mlp w przypadku modeli zbudowanych za pomocą zbioru z większą ilością zmiennych.## Stworzenie najlepszych modeli o parametrach wybranych przez przeszukiwanie siatkiOstatecznie decyduję się na stworzenie dwóch modeli lasu losowego dla każdego zbioru, ponieważ w każdym zestawieniu radził on sobie najlepiej. Jako `model1` będę od teraz oznaczał model bez dodatkowych zmiennych, drugi natomiast będzie oznaczany jako `model2`.```{r}best_results <- grid_results %>%extract_workflow_set_result("random_forest") %>%select_best(metric ="bal_accuracy")best_results2 <- grid_results2 %>%extract_workflow_set_result("random_forest") %>%select_best(metric ="bal_accuracy")```::: columns::: column**Model1**```{r echo=FALSE}best_results %>% select(mtry, trees, min_n)```:::::: column**Model2**```{r echo=FALSE}best_results2 %>% select(mtry, trees, min_n)```::::::Model lasu losowego wybrane metodą przeszukiwania siatki mają kolejno parametry:- `mtry` = 3,- `trees` = 1871,- `min_n` = 5dla `Model1` oraz- `mtry` = 6,- `trees` = 1927,- `min_n` = 5dla `Model2`.```{r}best_model_random_forest <-rand_forest(trees = best_results$trees,mtry = best_results$mtry,min_n = best_results$min_n) %>%set_engine("ranger", importance ="impurity") %>%set_mode("classification")best_model_random_forest2 <-rand_forest(trees = best_results2$trees,mtry = best_results2$mtry,min_n = best_results2$min_n) %>%set_engine("ranger", importance ="impurity") %>%set_mode("classification")```## Sprawdzenie dokładności modelu na zbiorze testowymOstatecznie o tym, który model spisuje się dokładniej i jaka jest faktyczna zdolność predykcyjna zbudowanych modeli można się przekonać sprawdzając ich zdolności na zbiorze testowym.```{r}rf_wflow <-workflow() %>%add_model(best_model_random_forest) %>%add_recipe(basic_recipe)rf_fit <- rf_wflow %>%fit(data = spotify_train)rf_wflow2 <-workflow() %>%add_model(best_model_random_forest2) %>%add_recipe(basic_recipe2)rf_fit2 <- rf_wflow2 %>%fit(data = spotify_train2)``````{r}test_pred <-predict(rf_fit, new_data = spotify_test) %>%bind_cols(spotify_test) metrics_set <-metric_set(accuracy, bal_accuracy, precision, recall, sensitivity, specificity)metrics_test <-metrics_set(test_pred,truth = genre, estimate = .pred_class)test_pred2 <-predict(rf_fit2, new_data = spotify_test2) %>%bind_cols(spotify_test2) metrics_test2 <-metrics_set(test_pred2,truth = genre, estimate = .pred_class)```::: columns::: column**Model1**```{r echo=FALSE}metrics_test```:::::: column**Model2**```{r echo=FALSE}metrics_test2```::::::Przy porównaniu metryk na zbiorach testowych ostatecznie widać, że `Model2` ma lepsze zdolności generalizacyjne. Wartość metryki `bal_accuracy` wynosząca 0.87 jest zadowalająca i oznacza, że model dobrze radzi sobie na danych, których wcześniej nie widział. Różnica między metrykami obydwu modeli nie jest bardzo duża, jednak z pewnością będzie zauważalna na macierzy klasyfikacji.### Macierze klasyfikacji```{r echo=FALSE}#| layout-ncol: 2#| column: pagecfm <- table(test_pred$genre, test_pred$.pred_class)cfm_df <- as.data.frame(cfm)names(cfm_df) <- c("Truth", "Prediction", "Freq")# Visualizationggplot(cfm_df, aes(x = Prediction, y = Truth, fill = Freq)) + geom_tile(color = "white") + geom_text(aes(label = Freq), vjust = 0.5, color = "white", size = 5) + scale_fill_gradient(low = "lightblue", high = "blue") + labs(title = "Macierz klasyfikacji Model1", x = "Wartość przewidywana", y = "Wartość prawdziwa") + theme_minimal() + theme(axis.text = element_text(size = 12), axis.title = element_text(size = 14), plot.title = element_text(hjust = 0.5), legend.position = "none")cfm2 <- table(test_pred2$genre, test_pred2$.pred_class)cfm_df2 <- as.data.frame(cfm2)names(cfm_df2) <- c("Truth", "Prediction", "Freq")# Visualizationggplot(cfm_df2, aes(x = Prediction, y = Truth, fill = Freq)) + geom_tile(color = "white") + geom_text(aes(label = Freq), vjust = 0.5, color = "white", size = 5) + scale_fill_gradient(low = "lightblue", high = "blue") + labs(title = "Macierz klasyfikacji Model2", x = "Wartość przewidywana", y = "Wartość prawdziwa") + theme_minimal() + theme(axis.text = element_text(size = 12), axis.title = element_text(size = 14), plot.title = element_text(hjust = 0.5))```Tutaj rzeczywiście rzuca się w oczy poprawa modelu o większej ilości zmiennych. Praktycznie każdy gatunek (z wyjątkiem folku i bluesa) ma teraz więcej poprawnie sklasyfikowanych obserwacji.Z powyższych wykresów można wysnuć wiele ciekawych wniosków:- każdy model najczęściej, bo aż 10 razy, pomylił blues z jazzem, co jest dosyć zrozumiałym błędem zważając na podobieństwo między tymi gatunkami- w `Model2``country` zostało pomylone z największą ilością gatunku, ponieważ oprócz rzeczywistej wartości model przewidywał w jego przypadku aż 6 innych gatunków muzycznych- hip-hop, jeżeli był mylnie klasyfikowany, to tylko jako reggae.Dzięki temu, że nie usunąłem ze zbioru zmiennej określającej tytuł piosenki mogę teraz dokładniej przyjrzeć się poszczególnym błędom. Najbardziej przykuł moją uwagę jeden utwór jazzowy, który w `Model1` został pomylony z utworem metalowym.```{r echo=FALSE}wrong_pred <- test_pred %>% select(track.name, genre, .pred_class) %>% filter(genre != .pred_class)wrong_pred2 <- test_pred2 %>% select(track.name, genre, .pred_class) %>% filter(genre != .pred_class)``````{r echo=FALSE}wrong_pred %>% filter(genre == "jazz" & .pred_class == "metal") %>% kable("html") %>% kable_styling("striped")```{{< video https://www.youtube.com/watch?v=dYxgci2uPno >}}Po jego wysłuchaniu staje się zrozumiałe, dlaczego model pomylił się w tym przypadku. Jest to utwór szybki, agresywny i ponury i mimo że jazzowy, to ma on w sobie elementy metalu. `Model2` nie popełnił już tego błędu; można podejrzewać, że stało się to za sprawą przywrócenia w zbiorze zmiennej `acousticness` - jest ona bowiem cechą, która zdecydowanie różni pomiędzy sobą te gatunki i najprawdopodobniej informacje, które ze sobą wniosła do `Model2` wyeliminowały tę pomyłkę.Inną pomyłką, którą można wziąć pod lupę jest utwór jazz sklasyfikowany jako country w `Model2`.```{r echo=FALSE}wrong_pred2 %>% filter(genre == "jazz" & .pred_class == "country") %>% kable("html") %>% kable_styling("striped")```{{< video https://www.youtube.com/watch?v=NHN6-yWFKPc >}}Po jego wysłuchaniu również można zrozumieć pomyłkę. Jest to zdecydowanie utwór jazzowy, jednak brzmi on też jednocześnie trochę jak "piosenka z westernu".Są tu też obecne pomyłki, dla których nie ma żadnego logicznego wyjaśnienia, jak sklasyfikowanie utworu Linkin Park "What I've Done" jako country, czy kompozycji Vivaldi'ego jako reggae.```{r echo=FALSE}wrong_pred2 %>% filter(genre == "metal" & .pred_class == "country") %>% kable("html") %>% kable_styling("striped")``````{r echo=FALSE}wrong_pred2 %>% filter(genre == "classical" & .pred_class == "reggae") %>% kable("html") %>% kable_styling("striped")```### Wykresy krzywych ROC```{r}#| layout-ncol: 2#| column: pagerf_wflow %>%last_fit(split) %>%collect_predictions() %>%roc_curve(truth = genre, .pred_blues, .pred_classical, .pred_country, .pred_folk, .pred_hiphop, .pred_jazz, .pred_metal, .pred_reggae, .pred_rock) %>%autoplot() +labs(title ="Model1") +theme(plot.title =element_text(hjust =0.5))rf_wflow2 %>%last_fit(split2) %>%collect_predictions() %>%roc_curve(truth = genre, .pred_blues, .pred_classical, .pred_country, .pred_folk, .pred_hiphop, .pred_jazz, .pred_metal, .pred_reggae, .pred_rock) %>%autoplot() +labs(title ="Model2") +theme(plot.title =element_text(hjust =0.5))```Z krzywych ROC stworzonych za pomocą metody one vs. all dla każdego gatunku można znaleźć potwierdzenie tego, co można było zobaczyć już na macierzy konfuzji - model najlepiej radził sobie z przewidywaniem metalu i hiphopu, natomiast najgorzej radził sobie z klasyfikacją bluesa, rocka i country.## Istotność cech w modelu```{r}f_imp <- rf_wflow %>%last_fit(split) %>%extract_fit_parsnip() %>%vip(num_features =13)f_imp2 <- rf_wflow2 %>%last_fit(split2) %>%extract_fit_parsnip() %>%vip(num_features =15)``````{r echo=FALSE}#| layout-ncol: 2#| column: pagef_imp + labs(title = "Model1") + theme(plot.title = element_text(hjust = 0.5))f_imp2 + labs(title = "Model2") + theme(plot.title = element_text(hjust = 0.5))```Z powyższego wykresu można zauważyć, że najważniejszą cechą przy podziałach stosowanych w tworzeniu optymalnego modelu lasu losowego `Model1` było `energy`, `danceability` oraz `danceability`. W `Modelu2` najważniejsza jest natomiast `decade`, a w drugiej kolejności `acousticness`, które na podstawie wysokiej korelacji zostało usunięte w pierwszym modelu. Widać więc, że korelację tą należało w tym przypadku zignorować, ponieważ nie dość że model `Model2` charakteryzował się lepszą dokładnością, to okazuje się, że wysoko skorelowana z inną zmienna `acousticness` w znacznym stopniu przyczyniła się do tworzenia podziałów w lasie losowym, który okazał się najlepszym modelem.W obu przypadkach najmniej przydały się natomiast zmienne `key`, `mode` oraz `time_signature`, co nie jest zaskoczeniem, ponieważ gatunki nigdy nie ogarniczają się do konkretnych skal ani struktur.Z powyższego wykresu można wyciągnąć jeszcze jeden ważny wniosek: ponieważ dekada okazuje się istotną zmienną, można przypuszczać, że stworzone modele nie sprawdziłyby się dobrze w klasyfikacji utworów spoza zbioru, na podstawie którego został zbudowany. Utwory w zbiorze nie były bowiem wybierane losowo – sam dobierałem playlisty, z których pochodzą. Często ograniczały się one do konkretnych dekad, o czym świadczą ich nazwy, takie jak "10s Metal Classics". W związku z tym nie można zakładać, że ilość utworów z danego gatunku w zbiorze jest reprezentatywna dla całego gatunku. Z tego powodu model prawdopodobnie nie byłby równie skuteczny, gdyby został przetestowany na zupełnie innym zbiorze testowym, ponieważ zmyliłyby go daty wydania, do których nie mógłby odnieść zasad, które tak dobrze działały w kontekście utworów z mojego zbioru.# PodsumowanieW ramach projektu dokonałem skutecznej klasyfikacji gatunków muzycznych na podstawie metadanych z serwisu Spotify. Proces rozpoczął się od pobrania danych przez API serwisu Spotify, które następnie poddałem eksploracyjnej analizie w celu zidentyfikowania kluczowych cech wpływających na klasyfikację. Dane zostały odpowiednio przygotowane, a następnie na ich podstawie zbudowałem i przetestowałem różne modele uczenia maszynowego. Najlepszy model osiągnął dokładność 87.1% na zbiorze testowym. Projekt potwierdził, że metadane Spotify mogą efektywnie wspierać klasyfikację gatunków muzycznych, jednak najprawdopodobniej nie będą one aż tak skuteczne w przewidywaniu gatunków utworów spoza zbioru, który stworzyłem. Można jednak mieć nadzieję, że przy pobraniu dużo większej ilości danych w sposób bardziej losowy, można by było stworzyć model, który dawałby znacznie lepsze rezultaty na nieznanych obserwacjach.